--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit e6e26e31cbab57a16ff3b7c25d3ed4d49b2428be
Parents : 52281a0
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-07T15:11:27-05:00
feat(MicronParser): improve resilience by adding error handling for parse failures and implement HTML escaping for fallback content
Changes
2 files changed, 234 insertions(+), 93 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/js/MicronParser.js b/meshchatx/src/frontend/js/MicronParser.js
index c5fc0f5a..9dbb7bc4 100644
--- a/meshchatx/src/frontend/js/MicronParser.js
+++ b/meshchatx/src/frontend/js/MicronParser.js
@@ -4,6 +4,16 @@ import BaseMicronParser from "micron-parser";
const ALLOWED_URI_REGEXP =
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|nomadnetwork|lxmf):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i;
+function escapeHtmlForFallback(text) {
+ if (text == null) return "";
+ return String(text)
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
/**
* Extends the published micron-parser with MeshChat / Nomad Network needs:
* partial includes, overlay style stripping, wide CJK monospace cells, and lxmf/nomadnetwork in DOMPurify.
@@ -118,120 +128,213 @@ export default class MicronParser extends BaseMicronParser {
convertMicronToHtml(markup, partialContents = {}) {
if (markup == null) return "";
if (typeof markup !== "string") markup = String(markup);
- let html = "";
- const headerColors = this.parseHeaderTags(markup);
+ const build = () => {
+ let html = "";
- const plainStyle = this.SELECTED_STYLES?.plain || { fg: this.DEFAULT_FG_DARK, bg: this.DEFAULT_BG };
- const defaultFg = headerColors.fg || plainStyle.fg;
- const defaultBg = headerColors.bg || plainStyle.bg;
+ let headerColors = { fg: null, bg: null };
+ try {
+ headerColors = this.parseHeaderTags(markup);
+ } catch (e) {
+ console.warn("MicronParser: parseHeaderTags failed", e);
+ }
- let state = {
- literal: false,
- depth: 0,
- fg_color: defaultFg,
- bg_color: defaultBg,
- formatting: {
- bold: false,
- underline: false,
- italic: false,
- strikethrough: false,
- },
- default_align: "left",
- align: "left",
- default_fg: defaultFg,
- default_bg: defaultBg,
- radio_groups: {},
- partialIndex: 0,
- };
+ const plainStyle = this.SELECTED_STYLES?.plain || { fg: this.DEFAULT_FG_DARK, bg: this.DEFAULT_BG };
+ const defaultFg = headerColors.fg || plainStyle.fg;
+ const defaultBg = headerColors.bg || plainStyle.bg;
- const lines = markup.split("\n");
+ let state = {
+ literal: false,
+ depth: 0,
+ fg_color: defaultFg,
+ bg_color: defaultBg,
+ formatting: {
+ bold: false,
+ underline: false,
+ italic: false,
+ strikethrough: false,
+ },
+ default_align: "left",
+ align: "left",
+ default_fg: defaultFg,
+ default_bg: defaultBg,
+ radio_groups: {},
+ partialIndex: 0,
+ };
- for (let line of lines) {
- const lineOutput = this.parseLine(line, state);
- if (lineOutput && lineOutput.length > 0) {
- for (let el of lineOutput) {
- if (el.classList && el.classList.contains("mu-partial")) {
- const id = el.getAttribute("data-partial-id");
- if (id && partialContents[id]) {
- html += partialContents[id];
- } else {
- html += el.outerHTML;
+ const lines = markup.split("\n");
+
+ for (let line of lines) {
+ let lineOutput;
+ try {
+ lineOutput = this.parseLine(line, state);
+ } catch (e) {
+ console.warn("MicronParser: parseLine failed", e);
+ html += `<span class="mu-line-parse-fallback" style="white-space:pre-wrap">${escapeHtmlForFallback(line)}</span><br>`;
+ continue;
+ }
+ if (lineOutput && lineOutput.length > 0) {
+ for (let el of lineOutput) {
+ try {
+ if (el.classList && el.classList.contains("mu-partial")) {
+ const id = el.getAttribute("data-partial-id");
+ if (id && partialContents[id]) {
+ html += partialContents[id];
+ } else {
+ html += el.outerHTML;
+ }
+ } else {
+ html += el.outerHTML;
+ }
+ } catch (e) {
+ console.warn("MicronParser: line output serialization failed", e);
+ html += `<span class="mu-line-parse-fallback" style="white-space:pre-wrap">${escapeHtmlForFallback(line)}</span><br>`;
+ break;
}
- } else {
- html += el.outerHTML;
}
+ } else if (lineOutput && lineOutput.length === 0) {
+ // skip
+ } else {
+ html += "<br>";
}
- } else if (lineOutput && lineOutput.length === 0) {
- // skip
- } else {
- html += "<br>";
}
- }
+
+ try {
+ const sanitized = DOMPurify.sanitize(html, {
+ USE_PROFILES: { html: true },
+ ALLOWED_URI_REGEXP,
+ });
+ try {
+ return MicronParser.stripOverlayStyles(sanitized);
+ } catch (e) {
+ console.warn("MicronParser: stripOverlayStyles failed", e);
+ return sanitized;
+ }
+ } catch (error) {
+ console.warn(
+ "DOMPurify is not installed or sanitization failed. Include dompurify or check the build.",
+ error
+ );
+ return `<p style="color: red;">DOMPurify is not installed or sanitization failed.</p>`;
+ }
+ };
try {
- const sanitized = DOMPurify.sanitize(html, {
- USE_PROFILES: { html: true },
- ALLOWED_URI_REGEXP,
- });
- return MicronParser.stripOverlayStyles(sanitized);
- } catch (error) {
- console.warn(
- "DOMPurify is not installed. Include it above micron-parser.js or run npm install dompurify ",
- error
- );
- return `<p style="color: red;"> ⚠ DOMPurify is not installed. Include it above micron-parser.js or run npm install dompurify </p>`;
+ return build();
+ } catch (e) {
+ console.warn("MicronParser: convertMicronToHtml failed", e);
+ const escaped = escapeHtmlForFallback(markup);
+ try {
+ return DOMPurify.sanitize(
+ `<pre class="mu-parse-fallback" style="white-space:pre-wrap">${escaped}</pre>`,
+ {
+ USE_PROFILES: { html: true },
+ ALLOWED_URI_REGEXP,
+ }
+ );
+ } catch {
+ return `<pre class="mu-parse-fallback" style="white-space:pre-wrap">${escaped}</pre>`;
+ }
}
}
convertMicronToFragment(markup) {
- const fragment = document.createDocumentFragment();
+ if (markup == null) {
+ return document.createDocumentFragment();
+ }
+ if (typeof markup !== "string") markup = String(markup);
- const headerColors = this.parseHeaderTags(markup);
+ try {
+ const fragment = document.createDocumentFragment();
- const plainStyle = this.SELECTED_STYLES?.plain || { fg: this.DEFAULT_FG_DARK, bg: this.DEFAULT_BG };
- const defaultFg = headerColors.fg || plainStyle.fg;
- const defaultBg = headerColors.bg || plainStyle.bg;
+ let headerColors = { fg: null, bg: null };
+ try {
+ headerColors = this.parseHeaderTags(markup);
+ } catch (e) {
+ console.warn("MicronParser: parseHeaderTags failed", e);
+ }
- let state = {
- literal: false,
- depth: 0,
- fg_color: defaultFg,
- bg_color: defaultBg,
- formatting: {
- bold: false,
- underline: false,
- italic: false,
- strikethrough: false,
- },
- default_align: "left",
- align: "left",
- default_fg: defaultFg,
- default_bg: defaultBg,
- radio_groups: {},
- partialIndex: 0,
- };
+ const plainStyle = this.SELECTED_STYLES?.plain || { fg: this.DEFAULT_FG_DARK, bg: this.DEFAULT_BG };
+ const defaultFg = headerColors.fg || plainStyle.fg;
+ const defaultBg = headerColors.bg || plainStyle.bg;
- const lines = markup.split("\n");
+ let state = {
+ literal: false,
+ depth: 0,
+ fg_color: defaultFg,
+ bg_color: defaultBg,
+ formatting: {
+ bold: false,
+ underline: false,
+ italic: false,
+ strikethrough: false,
+ },
+ default_align: "left",
+ align: "left",
+ default_fg: defaultFg,
+ default_bg: defaultBg,
+ radio_groups: {},
+ partialIndex: 0,
+ };
- for (let line of lines) {
- line = DOMPurify.sanitize(line, {
- USE_PROFILES: { html: true },
- ALLOWED_URI_REGEXP,
- });
- const lineOutput = this.parseLine(line, state);
- if (lineOutput && lineOutput.length > 0) {
- for (let el of lineOutput) {
- fragment.appendChild(el);
+ const lines = markup.split("\n");
+
+ for (let line of lines) {
+ let sanitizedLine = line;
+ try {
+ sanitizedLine = DOMPurify.sanitize(line, {
+ USE_PROFILES: { html: true },
+ ALLOWED_URI_REGEXP,
+ });
+ } catch (e) {
+ console.warn("MicronParser: line sanitize failed", e);
+ }
+ let lineOutput;
+ try {
+ lineOutput = this.parseLine(sanitizedLine, state);
+ } catch (e) {
+ console.warn("MicronParser: parseLine failed", e);
+ const fallback = document.createElement("span");
+ fallback.className = "mu-line-parse-fallback";
+ fallback.style.whiteSpace = "pre-wrap";
+ fallback.textContent = line;
+ fragment.appendChild(fallback);
+ fragment.appendChild(document.createElement("br"));
+ continue;
+ }
+ if (lineOutput && lineOutput.length > 0) {
+ for (let el of lineOutput) {
+ try {
+ fragment.appendChild(el);
+ } catch (e) {
+ console.warn("MicronParser: appendChild failed", e);
+ const fallback = document.createElement("span");
+ fallback.className = "mu-line-parse-fallback";
+ fallback.style.whiteSpace = "pre-wrap";
+ fallback.textContent = line;
+ fragment.appendChild(fallback);
+ fragment.appendChild(document.createElement("br"));
+ break;
+ }
+ }
+ } else if (lineOutput && lineOutput.length === 0) {
+ // skip
+ } else {
+ fragment.appendChild(document.createElement("br"));
}
- } else if (lineOutput && lineOutput.length === 0) {
- // skip
- } else {
- fragment.appendChild(document.createElement("br"));
}
- }
- return fragment;
+ return fragment;
+ } catch (e) {
+ console.warn("MicronParser: convertMicronToFragment failed", e);
+ const fragment = document.createDocumentFragment();
+ const pre = document.createElement("pre");
+ pre.className = "mu-parse-fallback";
+ pre.style.whiteSpace = "pre-wrap";
+ pre.textContent = markup;
+ fragment.appendChild(pre);
+ return fragment;
+ }
}
parseLine(line, state) {
@@ -263,7 +366,16 @@ export default class MicronParser extends BaseMicronParser {
forceMonospace(line) {
let out = "";
- const charArr = [...new Intl.Segmenter().segment(line)].map((x) => x.segment);
+ let charArr;
+ try {
+ charArr = [...new Intl.Segmenter().segment(line)].map((x) => x.segment);
+ } catch {
+ try {
+ charArr = Array.from(line);
+ } catch {
+ charArr = line.split("");
+ }
+ }
for (let char of charArr) {
const cellClass = MicronParser.isWideMonospaceCell(char) ? "Mu-mnt-full" : "Mu-mnt";
out += "<span class='" + cellClass + "'>" + char + "</span>";
diff --git a/tests/frontend/MicronParser.test.js b/tests/frontend/MicronParser.test.js
index c2546cd9..9a886bc5 100644
--- a/tests/frontend/MicronParser.test.js
+++ b/tests/frontend/MicronParser.test.js
@@ -1,4 +1,4 @@
-import { describe, it, expect, beforeEach } from "vitest";
+import { describe, it, expect, beforeEach, vi } from "vitest";
import MicronParser from "@/js/MicronParser";
describe("MicronParser.js", () => {
@@ -656,4 +656,33 @@ describe("MicronParser.js", () => {
expect(() => parser.convertMicronToHtml(markup)).not.toThrow();
});
});
+
+ describe("resilience: parse failures", () => {
+ it("renders other lines when parseLine throws for one line", () => {
+ const spy = vi.spyOn(parser, "parseLine").mockImplementation(function (line, state) {
+ if (line === "BAD") {
+ throw new Error("forced parse failure");
+ }
+ return MicronParser.prototype.parseLine.call(this, line, state);
+ });
+ const html = parser.convertMicronToHtml("good line\nBAD\nanother");
+ spy.mockRestore();
+ expect(html).toContain("good line");
+ expect(html).toContain("mu-line-parse-fallback");
+ expect(html).toContain("BAD");
+ expect(html).toContain("another");
+ });
+
+ it("convertMicronToFragment returns a fragment when parseLine throws", () => {
+ const spy = vi.spyOn(parser, "parseLine").mockImplementation(function (line, state) {
+ if (line === "BAD") {
+ throw new Error("forced");
+ }
+ return MicronParser.prototype.parseLine.call(this, line, state);
+ });
+ const frag = parser.convertMicronToFragment("ok\nBAD\nok2");
+ spy.mockRestore();
+ expect(frag.childNodes.length).toBeGreaterThan(0);
+ });
+ });
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────